Skip to content

test(mcp): cover bazaar_search, ListTools, and unknown-tool paths - #524

Closed
AGWAM001 wants to merge 11 commits into
Divinity-Labs:mainfrom
AGWAM001:mcp-spend-controls-and-tool-coverage
Closed

AGWAM001 wants to merge 11 commits into
Divinity-Labs:mainfrom
AGWAM001:mcp-spend-controls-and-tool-coverage

Conversation

@AGWAM001

Copy link
Copy Markdown
Contributor

Closes #421

Summary

tests/mcp.test.ts only exercised the error paths of paid_call. This PR adds coverage for bazaar_search, ListTools, and the unknown-tool path, and makes simulated upstream errors resemble what the real library throws, so text-matching fragility is no longer hidden from the suite. This is a test-only change: no production code under src/mcp/ is modified.

Changes

bazaar_search

Success with a query and without a query
Empty result set
Non-2xx upstream response, asserting a structured error is returned instead of a best-effort result
Malformed response body, asserting a structured error

ListTools

Asserts the advertised tool names
Validates that each tool’s inputSchema is a well-formed JSON Schema object, so a malformed schema fails the suite

Unknown tool

Calling a tool name that isn’t registered returns the expected structured error rather than throwing or returning partial output

Simulated errors

Upstream errors are now built as Error instances with the properties the real library sets (name, message, and any code or cause fields), rather than plain strings
Added a case showing the current mapping behavior against these shapes
Testing
npx vitest run tests/mcp.test.ts: the four existing paid_call tests still pass unchanged, and the new tests pass
npm test: full suite run; Load test (prices) fails on main already and is unrelated to this PR
Safety notes
All tests use mocked transports and a throwaway test signer. No real or mainnet secret key is read, logged, echoed, or used.
No MCP_AGENT_SECRET_KEY_MAINNET is required to run these tests.
Addresses, contract ids, and keys in fixtures are written out in full, with none shortened or invented. Where a value isn’t needed, fixtures leave it out.
Compatibility

Test-only. No changes to BazaarMcpServer, src/mcp/codes.ts, or the example server and agent.

Checklist
bazaar_search tests for success, empty, non-2xx, and malformed body
ListTools exercised and each schema validated
Unknown-tool path covered
Simulated errors resemble real library errors
Existing four tests pass
No real secrets used or logged

AGWAM001 and others added 10 commits October 17, 2026 23:01
paid_call signs with a real mainnet key, and maxPrice only bounds one mistake —
a caller in a retry loop could spend without limit. Add a cumulative USD ceiling
that reserves each call's maximum before the payment runs and settles it after,
record every attempt in an audit ledger, and refuse to spend at all when a
durable ledger was asked for and cannot be written. The same tools are also
served over Streamable HTTP, which refuses to start without a bearer token since
it exposes paid_call to the network.

Cover Divinity-Labs#421: bazaar_search, ListTools and the unknown-tool path had no tests, and
the simulated upstream errors were plain strings that hid how handlePaidCallError
classifies on lowercased message substrings. Fixtures now use the shapes that
@x402/fetch, @x402/core and @x402/stellar really throw, including one Horizon
error whose op_no_trust signal is invisible to that matcher.
The payment ledger registered mcp_payment_* counters on the shared registry,
but the MCP process serves no listener that reads it, so the spend of an agent
was still only recoverable from the audit file. Serve GET /metrics from the MCP
HTTP transport, on the same bearer token as paid_call, and refuse to start when
MCP_HTTP_PATH would put the MCP handshake on that path.

The network label was always unknown in the shipped server because run.ts only
passed the host allow-list. Default the network domain to the two networks the
signer is configured for, overridable with MCP_METRIC_NETWORKS, so a series per
network exists without opening the label to whatever a seller echoes.
…ibraries throw

The original four fixtures were duck-typed stubs: bare Error strings and
plain-object responses. Rewire them on the actual chain: the @x402/stellar
simulation guard wrapped by @x402/fetch's payment-payload frame, and real
Response objects for the 402 and success paths. Drop the hand-rolled
handler captures for the file's createHarness.
… surface

The merge of main into this branch interleaved two independent rewrites of
src/mcp/server.ts (HTTP transport + spend ledger vs. allow-list + price tool +
SSRF + zod), leaving ~170 parse errors that broke `tsc --noEmit`. Reconstructed
the merge properly: union error codes with main's Divinity-Labs#480 structured classifier,
one coherent McpServerConfig/constructor, the paid_call path keeps our
fail-closed ledger and budget ordering around main's allow-list gate and
timeout, and examples/run.ts wires both features.

tests/mcp.test.ts: deduplicated auto-merged import blocks, pointed the harness
at a public DNS answer so main's SSRF guard lets discovery through, and updated
assertions to the merged error reasons. tests/mcp-http.test.ts: allow-list the
hosts its paid calls use and expect the upstream timeout signal.

Verified against this exact tree: tsc --noEmit EXIT=0 and full vitest suite
990 passed | 1 skipped.
The merge of main into this branch was resolved by keeping both sides of
every conflict, so server.ts held two constructors, two CallTool handlers
and returns stacked behind returns — 29 parse errors, and the typecheck
never reached the semantic error the same resolution left in codes.ts
(ERR_BUDGET_EXCEEDED twice).

Resolve each conflict on intent instead: the HTTP transport builds one
protocol server per request, so main's resources/list and resources/read
handlers register on the passed-in target rather than this.server;
bazaar_search and paid_call keep main's paging and byte caps, with the
payment ledger still recording every attempt that reached the transport;
payment_budget reports recordedSpend inside the bounded result.

The two branch-side discovery tests asserted the pre-paging URLs, so they
now expect the limit and offset main's resolvePage adds.
…kfile

The lockfile was last regenerated on Windows, so every optional dependency
for another platform lost its package entry: 25 @esbuild/*, 16
@rolldown/binding-*, 10 lightningcss-*, 5 @msgpackr-extract/*, fsevents,
and the 6 @emnapi/@napi-rs/@tybys wasm helpers those pull in. `npm ci`
still succeeds — it installs exactly what the lock names — so the runner
gets rolldown with no native binding and vitest 4 dies before collecting a
single test: "Cannot find module '@rolldown/binding-linux-x64-gnu'".

main is red at this same step for the same reason, so every branch merged
from it inherits a failing `npm test` that no change to src/ or tests/ can
clear.

Restore all 63 entries from the pre-merge lockfile, whose versions match
what esbuild, rolldown, lightningcss, msgpackr-extract and prisma still ask
for here, and check each integrity hash and tarball URL against
registry.npmjs.org. Nothing else moves: the 483 entries already in the
lock are byte-identical, and `npm ci --dry-run` resolves on linux-x64
(picking up @rolldown/binding-linux-x64-gnu) and on win32-x64.
main's drift guard now scans examples/ and scripts/ as well as src/
(Divinity-Labs#413), and examples/mcp-server/run.ts reads MCP_METRIC_NETWORKS, so the
merged tree fails the guard on a variable this branch introduced.

It was commented out on purpose: an empty value reports every network as
`unknown`. Spell out the default the code already falls back to
(DEFAULT_MCP_METRIC_NETWORKS) instead, which documents the variable
without changing what an operator copying this file gets.
@AGWAM001

Copy link
Copy Markdown
Contributor Author

pls review

Miracle656 added a commit that referenced this pull request Oct 23, 2026
Serves the same MCP tools over Streamable HTTP alongside stdio, with a spend
audit trail readable through GET /metrics on the same listener.

The security shape is right, and I checked it rather than taking the
docstring's word:

- runHttp refuses to start without a non-empty bearer token, with an explicit
  error rather than a silent default. That matters because this transport
  exposes paid_call — a tool that can spend real money — to anything that can
  reach the port.
- Bearer comparison is constant-time over SHA-256 digests. Hashing first is
  not decoration: timingSafeEqual throws on a length mismatch, so comparing
  raw tokens would leak the expected length through the exception path.
- Default bind is 127.0.0.1, not 0.0.0.0, and the port is range-validated.

Conflict: package-lock.json only. Took main's copy — this branch changes no
dependency, every new import resolves to something already installed
(@modelcontextprotocol/sdk/server/streamableHttp.js, prom-client, node
builtins), and main's lockfile is the one that carries the @rolldown Linux
binding CI needs, which #542 restored an hour ago.

Lens: 112 files, 1,397 tests passing, typecheck clean.

Note for #524: it touches the same twelve files from a branch based 36
commits back, so it is not a rebase of this. Asking the author which work is
still live before doing anything with it.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
@Miracle656

Copy link
Copy Markdown
Collaborator

I've merged #521 to main as c55a980, and I need you to tell me what's still live here before I touch this one.

The two PRs are not versions of the same branch. They change the identical twelve files and carry both of the same changesets (mcp-http-transport.md, mcp-payment-audit.md), they were opened an hour apart, and you pushed to both within fifteen minutes of each other. But they are divergent, not stacked:

So this branch is missing 21 commits of work that #521 had, and a diff between the two heads is ~9,900 deletions. Merging it as-is would revert a lot of main, which is why I've stopped rather than resolving it myself.

There is real work here that #521 does not have — the MCP file diff between them is about 1,167 added lines, most of it tests/mcp.test.ts (bazaar_search, ListTools, unknown-tool coverage), plus changes to src/mcp/server.ts, codes.ts and examples/mcp-server/run.ts. That test coverage is worth having; I just can't tell from the outside which side of each overlapping change you consider current.

What would help: rebase this onto main now that #521 has landed, and keep only what #521 didn't bring. If it turns out to be test coverage alone, that's a clean, easy PR to review and I'll take it quickly. If you'd rather, close this and open a fresh one with just the coverage — either is fine.

One thing you can drop on the way: .env.example has MCP_MAX_PAYMENT_PRICE= twice on main already (once under "Ceiling per paid_call", once under "MCP agent spending limits"). In a dotenv file the last one wins, so the first comment block is describing a line that never takes effect. That's pre-existing rather than yours, but you're the person in this file — worth folding into whichever PR you keep.

@AGWAM001

Copy link
Copy Markdown
Contributor Author

Audited against main as requested. Result: nothing on this branch survives #521, so I am closing in favour of #607, which carries the one change that was still live.

What main already has. c55a980 brought the coverage, so I compared test bodies (comments stripped, whitespace normalised), not titles:

  • 69 shared it() bodies byte-identical to main's
  • 34 titles duplicated inside this branch's own tests/mcp.test.ts
  • 4 shared titles with different bodies - the four legacy paid_call cases, which main already carries in improved form under BazaarMcpServer paid_call error classification
  • 4 titles unique to this branch: two re-title main's maps a transport failure to ERR_INTERNAL FAILURE... and reports an unregistered scheme client as ERR_INTERNAL_FAILURE... (same fixtures, same assertions), one re-titles main's records every paid call, whatever the transport did with it, and rejects instead of returning a best-effort result asserts the pre-An unknown tool name throws a raw exception #415 throw that main deliberately inverted - porting it would add a failing test.

Verified on main (89bc678): tsc --noEmit exit 0, and tests/mcp.test.ts + tests/mcp-http.test.ts = 104 passed. #421 is satisfied by what landed.

Why this head cannot merge. f5a2e07 (main merged in) interleaved both sides of every conflict: src/mcp/server.ts:886 carries the handleHttpRequest parameters written twice, so tsc reports 7 TS1128/1109/1003/1005 syntax errors - that is the red Typecheck & build on this head - and tests/mcp.test.ts fails to transform with 20 duplicate-identifier errors, so CI runs Tests: no tests for it. .env.example (+43), README.md (+161), examples/mcp-server/run.ts (+41), src/mcp/codes.ts (+2, re-declaring ERR_UNAUTHORIZED and ERR_BUDGET_EXCEEDED main already has) and package-lock.json (+379) are re-additions of what #521 landed.

Still live, moved to #607: the .env.example duplicate MCP_MAX_PAYMENT_PRICE you flagged - the earlier # Ceiling per paid_call pair never applied because dotenv takes the last declaration. 1 file, -2 lines; envDrift.test.ts 4 passed, tsc and npm run build exit 0.

@AGWAM001

This comment has been minimized.

@AGWAM001

Copy link
Copy Markdown
Contributor Author

Closing per the audit above. The one change still live here is split out to #607, and the coverage #421 asked for is already on main via c55a980.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Only the error paths of one tool are tested

2 participants